SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
4 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
21.4 KB · 149 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { getListing, getProperty, nearbyListings, neighbourhoodScore, recentTransactions } from "@/lib/queries";5import { estimateForListing, estimateManual } from "@/lib/estimate";6import { dbAvailable } from "@/lib/db";7import { fmtPrice, fmtArea, fmtDate, fmtInt, daysSince } from "@/lib/format";8import { CATEGORY_LABEL } from "@/lib/normalize";9import { Gallery } from "@/components/gallery";10import { ListingCardView } from "@/components/listing-card";11import { VerdictPill } from "@/components/verdict-pill";12import { Notice } from "@/components/ui";13import { IconExternal, IconBed, IconBath, IconArea, IconPin } from "@/components/icons";14import { ListingTabs } from "./listing-tabs";15import { ListingActions } from "./listing-actions";16import { ContactPanel } from "@/components/contact-panel";17import { ReportButton } from "@/components/report-button";18import { insights, marketPosition, extractFinancials, rentalBudget } from "@/lib/insights";19import { DIRECT_PREFIX, getDirectBySlug, publicAddress } from "@/lib/listings/public";20import { incrementViews } from "@/lib/listings/service";21import { mediaUrl } from "@/lib/listings/media";22import { PROPERTY_TYPE_LABEL } from "@/lib/listings/types";2324export const dynamic = "force-dynamic";25type Props = { params: Promise<{ uid: string }> };26const parseJson = <T,>(s: string | null | undefined, fb: T): T => { try { const v = JSON.parse(s ?? ""); return (v ?? fb) as T; } catch { return fb; } };2728export async function generateMetadata({ params }: Props): Promise<Metadata> {29  const { uid: raw } = await params; const uid = decodeURIComponent(raw);30  if (uid.startsWith(DIRECT_PREFIX)) { const d = await getDirectBySlug(uid.slice(DIRECT_PREFIX.length)).catch(() => null); if (!d || d.l.status !== "PUBLISHED") return { title: "Annonce introuvable" }; const rent = d.l.listingType === "RENT"; const title = `${d.l.title ?? PROPERTY_TYPE_LABEL[d.p.propertyType]}, ${d.p.city} — ${fmtPrice(d.card.price, { rent })}`; return { title, description: (d.l.description ?? "").slice(0, 160), openGraph: { title, images: d.media.slice(0, 1).map((m) => `https://www.toveli.ca${mediaUrl(m.storageKey, 1600)}`) }, alternates: { canonical: `/annonce/${encodeURIComponent(uid)}` } }; }31  if (!dbAvailable()) return {};32  const l = getListing(uid); if (!l) return { title: "Annonce introuvable" };33  const rent = l.market === "rent"; const title = `${l.address ?? CATEGORY_LABEL[l.category]}, ${l.city} — ${fmtPrice(l.price, { rent })}`;34  return { title, description: (l.description ?? "").slice(0, 160) || `${CATEGORY_LABEL[l.category]} ${rent ? "à louer" : "à vendre"} à ${l.city}.`, openGraph: { title, images: parseJson<string[]>(l.images, []).slice(0, 1) }, alternates: { canonical: `/annonce/${encodeURIComponent(l.uid)}` } };35}3637function Crumbs({ rent, region, city }: { rent: boolean; region: string | null; city: string | null }) {38  const m = rent ? "location" : "achat";39  return <nav className="text-xs text-ink-3 mb-3 flex gap-1.5 flex-wrap" aria-label="Fil d'Ariane"><Link href="/" className="hover:underline">Accueil</Link> › <Link href={`/recherche?marche=${m}`} className="hover:underline">{rent ? "Louer" : "Acheter"}</Link>{region && <> › <Link href={`/recherche?marche=${m}&region=${encodeURIComponent(region)}`} className="hover:underline">{region}</Link></>}{city && <> › <Link href={`/recherche?marche=${m}&ville=${encodeURIComponent(city)}`} className="hover:underline">{city}</Link></>}</nav>;40}41function Chips({ typeLabel, bedrooms, bathrooms, powder, area, lot, year, availability, rent, extra = [] }: { typeLabel: string; bedrooms: number | null; bathrooms: number | null; powder?: number | null; area: number | null; lot: number | null; year: number | null; availability?: string | null; rent: boolean; extra?: string[] }) {42  return <ul className="mt-3 flex flex-wrap gap-2 text-sm">43    <li className="tv-chip">{typeLabel}</li>44    {bedrooms != null && <li className="tv-chip"><IconBed width={15} height={15} /> {fmtInt(bedrooms)} chambre{bedrooms > 1 ? "s" : ""}</li>}45    {bathrooms != null && <li className="tv-chip"><IconBath width={15} height={15} /> {fmtInt(bathrooms)} salle{bathrooms > 1 ? "s" : ""} de bain{powder ? ` + ${powder} s. d'eau` : ""}</li>}46    {area != null && area > 0 && <li className="tv-chip"><IconArea width={15} height={15} /> {fmtArea(area)} <span className="text-ink-3">({fmtArea(area, "m2")})</span></li>}47    {lot != null && lot > 0 && <li className="tv-chip">Terrain {fmtArea(lot)}</li>}48    {year != null && <li className="tv-chip">Construite en {year}</li>}49    {availability && rent && <li className="tv-chip">Disponible : {availability === "now" ? "immédiatement" : availability}</li>}50    {extra.map((e) => <li key={e} className="tv-chip">{e}</li>)}51  </ul>;52}5354/* ───────────── Annonce directe Toveli ───────────── */55async function DirectPage({ slug }: { slug: string }) {56  const d = await getDirectBySlug(slug).catch(() => null); if (!d || d.l.status !== "PUBLISHED") notFound();57  await incrementViews([d.l.id]).catch(() => {});58  const rent = d.l.listingType === "RENT"; const c = d.card; const p = d.p; const l = d.l;59  const images = d.media.map((m) => mediaUrl(m.storageKey, 1600));60  const features = (l.inclusions as string[]) ?? []; const details: Record<string, unknown> = { ...(l.features as Record<string, unknown>), ...(l.parking ? { Stationnement: l.parking } : {}), ...(l.furnished != null ? { Meublé: l.furnished ? "Oui" : "Non" } : {}), ...(l.petsPolicy ? { Animaux: { allowed: "Acceptés", not_allowed: "Refusés", ask: "À discuter" }[l.petsPolicy] } : {}), ...(l.smokingPolicy ? { Fumeur: l.smokingPolicy === "allowed" ? "Permis" : "Non-fumeur" } : {}), ...(l.leaseTerms ? { Bail: l.leaseTerms } : {}) };61  const live = !rent && p.lat != null && p.lng != null && !["terrain", "commercial"].includes(p.propertyType) ? estimateManual({ lat: p.lat, lng: p.lng, category: c.category, floorAreaSqft: p.areaSqft, yearBuilt: p.yearBuilt, landAreaSqft: p.lotSqft, label: c.address ?? "Propriété", city: p.city }) : null;62  const score = p.lat != null && p.lng != null ? neighbourhoodScore(p.lat, p.lng) : null;63  const nearby = p.lat != null && p.lng != null ? nearbyListings(p.lat, p.lng, c.market, "", 6) : [];64  const sales = !rent && p.lat != null && p.lng != null ? recentTransactions(p.lat, p.lng, 8) : [];65  const pos = marketPosition({ market: c.market, category: c.category, city: p.city, region: p.region, price: c.price, area_sqft: p.areaSqft, first_seen: c.firstSeen });66  const ins = insights({ market: c.market, category: c.category, city: p.city, price: c.price, area_sqft: p.areaSqft, lot_sqft: p.lotSqft, year_built: p.yearBuilt, bedrooms: c.bedrooms, first_seen: c.firstSeen, image_count: images.length, type_label: c.typeLabel }, live ? { estimate: live.result.estimate, ratio: c.price ? live.result.estimate / c.price : null, verdict: null, confidence: live.result.confidenceLevel } : null, pos, 0, null, score);67  const budget = rent ? rentalBudget({ price: c.price, area_sqft: p.areaSqft, features, details }) : null;68  const publisher = l.sourceType === "DIRECT_AGENT" ? "Publiée par un représentant" : "Publiée par le propriétaire";69  const showName = (d.owner?.prefs as { privacy?: { showNameOnListings?: boolean } } | null)?.privacy?.showNameOnListings !== false;70  const jsonLd = { "@context": "https://schema.org", "@type": rent ? "Apartment" : "SingleFamilyResidence", name: l.title ?? undefined, description: (l.description ?? "").slice(0, 500), address: { "@type": "PostalAddress", streetAddress: publicAddress(p) ?? undefined, addressLocality: p.city ?? undefined, addressRegion: "QC", postalCode: p.privacyLevel === "EXACT" ? p.postalCode ?? undefined : undefined, addressCountry: "CA" }, geo: c.lat != null ? { "@type": "GeoCoordinates", latitude: c.lat, longitude: c.lng } : undefined, numberOfRooms: c.bedrooms ?? undefined, floorSize: p.areaSqft ? { "@type": "QuantitativeValue", value: p.areaSqft, unitCode: "FTK" } : undefined, offers: { "@type": "Offer", price: c.price ?? undefined, priceCurrency: "CAD", availability: "https://schema.org/InStock" }, image: images.slice(0, 3).map((i) => `https://www.toveli.ca${i}`) };71  return (72    <article className="mx-auto max-w-7xl px-4 py-4 md:py-6">73      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />74      <Crumbs rent={rent} region={p.region} city={p.city} />75      <Gallery images={images} credit={`${publisher.toLowerCase()} sur Toveli`} />76      <div className="mt-5 grid lg:grid-cols-[minmax(0,1fr)_380px] gap-6">77        <div className="min-w-0">78          <header>79            <div className="flex flex-wrap items-center gap-2"><p className="tv-price text-3xl md:text-4xl">{fmtPrice(c.price, { rent })}</p><span className="tv-badge bg-brand-soft text-brand-dark">{rent ? "À louer" : "À vendre"}</span><span className="tv-badge bg-good-soft text-good">{publisher}</span></div>80            <h1 className="mt-2 text-xl md:text-2xl font-bold tracking-tight">{l.title ?? c.typeLabel}</h1>81            <p className="text-ink-2 flex items-center gap-1"><IconPin width={16} height={16} /> {c.address ?? "Adresse communiquée après contact"}{p.unit && p.privacyLevel === "EXACT" ? `, app. ${p.unit}` : ""} · {p.city}{p.region ? `, ${p.region}` : ""}</p>82            <Chips typeLabel={c.typeLabel ?? ""} bedrooms={c.bedrooms} bathrooms={c.bathrooms} area={p.areaSqft} lot={p.lotSqft} year={p.yearBuilt} availability={l.availabilityDate} rent={rent} extra={[...(l.furnished ? ["Meublé"] : []), ...(l.petsPolicy === "allowed" ? ["Animaux acceptés"] : [])]} />83            <div className="lg:hidden mt-4"><ContactPanel listingId={l.id} uid={c.uid} publisherLabel={publisher} publisherName={showName ? d.publicName ?? null : null} /></div>84            <p className="mt-3 text-xs text-ink-3">Annonce n° {l.slug} · publiée le {fmtDate(l.publicationAt?.toISOString())} · mise à jour le {fmtDate(l.updatedAt.toISOString())} · {fmtInt(l.viewsCount)} vues{p.privacyLevel !== "EXACT" ? " · adresse exacte masquée à la demande de l'annonceur" : ""}.</p>85          </header>86          <ListingTabs listing={{ uid: c.uid, market: c.market, price: c.price, description: l.description, features, details, lat: c.lat, lng: c.lng, category: c.category, city: p.city, address: c.address, area_sqft: p.areaSqft, year_built: p.yearBuilt, bedrooms: c.bedrooms, bathrooms: c.bathrooms, direct: true }} valuation={null} live={live} priceEvents={[]} score={score} sales={sales} property={null} insights={ins} position={pos} financials={{ municipalTax: Number((l.features as Record<string, unknown>).taxes_municipales) || null, schoolTax: Number((l.features as Record<string, unknown>).taxes_scolaires) || null, assessment: null, condoFees: Number((l.features as Record<string, unknown>).frais_condo) || null }} budget={budget} />87        </div>88        <aside className="space-y-4">89          <div className="hidden lg:block"><ContactPanel listingId={l.id} uid={c.uid} publisherLabel={publisher} publisherName={showName ? d.publicName ?? null : null} /></div>90          {live && <div className="tv-card p-4"><p className="tv-label">Estimation Toveli (indicative)</p><p className="tv-price text-2xl mt-1">{fmtPrice(live.result.estimate)}</p><p className="text-xs text-ink-2">{fmtPrice(live.result.low)} – {fmtPrice(live.result.high)} · confiance {live.result.confidenceLevel} ({live.result.confidencePct} %)</p><Notice>Calculée à partir de ventes comparables autour de la position déclarée ; l'annonceur n'intervient pas dans ce calcul. Pas une évaluation agréée.</Notice></div>}91          <div className="tv-card p-4"><p className="tv-label">Provenance</p><dl className="mt-2 text-xs text-ink-2 space-y-1"><div className="flex justify-between gap-2"><dt>Source</dt><dd>Publication directe sur Toveli</dd></div><div className="flex justify-between gap-2"><dt>Annonceur</dt><dd>{l.sourceType === "DIRECT_AGENT" ? "représentant (non vérifié)" : "propriétaire (courriel vérifié)"}</dd></div><div className="flex justify-between gap-2"><dt>Photos</dt><dd>droits déclarés par l'annonceur, EXIF retirés</dd></div><div className="flex justify-between gap-2"><dt>Modération</dt><dd>{l.moderationState === "HUMAN_OK" ? "vérifiée par Toveli" : "contrôle automatique"}</dd></div></dl></div>92        </aside>93      </div>94      {nearby.length > 0 && <section className="mt-10"><h2 className="text-xl font-bold tracking-tight mb-3">À proximité</h2><div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">{nearby.map((n) => <ListingCardView key={n.uid} c={n} />)}</div></section>}95    </article>96  );97}9899/* ───────────── Annonce agrégée ───────────── */100export default async function Annonce({ params }: Props) {101  const { uid: raw } = await params; const uid = decodeURIComponent(raw);102  if (uid.startsWith(DIRECT_PREFIX)) return <DirectPage slug={uid.slice(DIRECT_PREFIX.length)} />;103  if (!dbAvailable()) notFound();104  const l = getListing(uid); if (!l) notFound();105  const rent = l.market === "rent"; const prop = getProperty(l.property_id);106  const images = parseJson<string[]>(l.images, []); const features = parseJson<unknown[]>(l.features, []).map(String); const details = (() => { const d = parseJson<unknown>(l.details, {}); return d && typeof d === "object" && !Array.isArray(d) ? d as Record<string, unknown> : {}; })();107  const live = !rent && l.lat != null ? estimateForListing(l, prop?.role_unit_id ?? null) : null;108  const score = l.lat != null && l.lng != null ? neighbourhoodScore(l.lat, l.lng) : null;109  const nearby = l.lat != null && l.lng != null ? nearbyListings(l.lat, l.lng, l.market, l.uid, 6) : [];110  const sales = !rent && l.lat != null && l.lng != null ? recentTransactions(l.lat, l.lng, 8) : [];111  const siblings = (prop?.listings ?? []).filter((x) => x.uid !== l.uid);112  const days = daysSince(l.first_seen); const credit = l.connector.replace(/_/g, " ");113  const pos = marketPosition({ market: l.market, category: l.category, city: l.city, region: l.region, price: l.price, area_sqft: l.area_sqft, first_seen: l.first_seen });114  const drops = l.priceEvents.filter((e, i, a) => i > 0 && e.price < a[i - 1].price).length;115  const val = l.valuation ? { estimate: l.valuation.estimate, ratio: l.valuation.ratio, verdict: l.valuation.verdict, confidence: l.valuation.confidence } : live ? { estimate: live.result.estimate, ratio: l.price ? live.result.estimate / l.price : null, verdict: null, confidence: live.result.confidenceLevel } : null;116  const ins = insights(l, val, pos, drops, prop?.role_value ?? null, score);117  const fin = extractFinancials(details); if (!fin.assessment && prop?.role_value) fin.assessment = prop.role_value;118  const budget = rent ? rentalBudget({ price: l.price, area_sqft: l.area_sqft, features, details }) : null;119  const jsonLd = { "@context": "https://schema.org", "@type": rent ? "Apartment" : "SingleFamilyResidence", name: l.address ?? undefined, address: { "@type": "PostalAddress", streetAddress: l.address ?? undefined, addressLocality: l.city ?? undefined, addressRegion: "QC", addressCountry: "CA" }, geo: l.lat != null ? { "@type": "GeoCoordinates", latitude: l.lat, longitude: l.lng } : undefined, numberOfRooms: l.bedrooms ?? undefined, floorSize: l.area_sqft ? { "@type": "QuantitativeValue", value: l.area_sqft, unitCode: "FTK" } : undefined, offers: { "@type": "Offer", price: l.price ?? undefined, priceCurrency: "CAD", url: l.url ?? undefined, availability: "https://schema.org/InStock" }, image: images.slice(0, 3) };120  return (121    <article className="mx-auto max-w-7xl px-4 py-4 md:py-6">122      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />123      <Crumbs rent={rent} region={l.region} city={l.city} />124      <Gallery images={images} credit={credit} />125      <div className="mt-5 grid lg:grid-cols-[minmax(0,1fr)_380px] gap-6">126        <div className="min-w-0">127          <header>128            <div className="flex flex-wrap items-center gap-2"><p className="tv-price text-3xl md:text-4xl">{fmtPrice(l.price, { rent })}</p><span className="tv-badge bg-brand-soft text-brand-dark">{rent ? "À louer" : "À vendre"}</span>{l.valuation && <VerdictPill verdict={l.valuation.verdict} ratio={l.valuation.ratio} confidence={l.valuation.confidence} />}</div>129            <h1 className="mt-2 text-xl md:text-2xl font-bold tracking-tight">{l.address ?? CATEGORY_LABEL[l.category]}{l.unit ? `, app. ${l.unit}` : ""}</h1>130            <p className="text-ink-2 flex items-center gap-1"><IconPin width={16} height={16} /> {l.city}{l.sector ? ` (${l.sector})` : ""}{l.region ? `, ${l.region}` : ""}</p>131            <Chips typeLabel={l.type_label ?? CATEGORY_LABEL[l.category]} bedrooms={l.bedrooms} bathrooms={l.bathrooms} powder={l.powder_rooms} area={l.area_sqft} lot={l.lot_sqft} year={l.year_built} availability={l.availability} rent={rent} extra={[...(l.furnished ? ["Meublé"] : []), ...(l.pets && /oui|accept|allowed|yes/i.test(l.pets) ? ["Animaux acceptés"] : [])]} />132            <ListingActions uid={l.uid} url={l.url} connector={credit} />133            <p className="mt-3 text-xs text-ink-3">Source : <strong className="text-ink-2">{credit}</strong>{l.agency ? ` · ${l.agency}` : ""}{l.mls ? ` · n° ${l.mls}` : ""} · vue pour la première fois le {fmtDate(l.first_seen)}{days != null ? ` (il y a ${days} j)` : ""} · dernière vérification le {fmtDate(l.last_seen)}.</p>134          </header>135          <ListingTabs listing={{ uid: l.uid, market: l.market, price: l.price, description: l.description, features, details, lat: l.lat, lng: l.lng, category: l.category, city: l.city, address: l.address, area_sqft: l.area_sqft, year_built: l.year_built, bedrooms: l.bedrooms, bathrooms: l.bathrooms, direct: false }} valuation={l.valuation} live={live} priceEvents={l.priceEvents} score={score} sales={sales} property={prop ? { id: prop.id, role_value: prop.role_value, role_land: prop.role_land, role_building: prop.role_building, key_kind: prop.key_kind, dedup_confidence: prop.dedup_confidence, n_listings: prop.n_listings } : null} insights={ins} position={pos} financials={fin} budget={budget} />136        </div>137        <aside className="space-y-4">138          <div className="tv-card p-4"><p className="tv-label">Annonce d'origine</p>{l.url ? <a href={l.url} target="_blank" rel="noopener nofollow" className="tv-btn tv-btn-primary w-full mt-2">Voir sur {credit} <IconExternal width={16} height={16} /></a> : <p className="text-sm text-ink-3 mt-1">Lien non disponible</p>}<p className="mt-2 text-xs text-ink-3">Toveli ne gère ni visite ni transaction pour les annonces agrégées : contactez le vendeur, le courtier ou le propriétaire via l'annonce d'origine.</p><div className="mt-2"><ReportButton uid={l.uid} /></div></div>139          {siblings.length > 0 && <div className="tv-card p-4"><p className="tv-label">Même propriété · {siblings.length} autre{siblings.length > 1 ? "s" : ""} annonce{siblings.length > 1 ? "s" : ""}</p><p className="text-xs text-ink-3 mt-1">Rapprochement par {prop?.key_kind === "role" ? "unité du rôle d'évaluation" : prop?.key_kind === "address" ? "adresse normalisée" : "coordonnées"} (confiance {Math.round((prop?.dedup_confidence ?? 0) * 100)} %).</p><div className="mt-3 space-y-2">{siblings.slice(0, 4).map((s) => <ListingCardView key={s.uid} c={s} compact />)}</div>{prop && <Link href={`/propriete/${prop.id}`} className="block mt-3 text-sm font-semibold text-brand-dark hover:underline">Voir la fiche de la propriété →</Link>}</div>}140          {!rent && (live || l.valuation) && <div className="tv-card p-4"><p className="tv-label">Valeur estimée Toveli</p><p className="tv-price text-2xl mt-1">{fmtPrice(live?.result.estimate ?? l.valuation?.estimate)}</p><p className="text-xs text-ink-2">Fourchette {fmtPrice(live?.result.low ?? l.valuation?.low)} – {fmtPrice(live?.result.high ?? l.valuation?.high)} · confiance {live?.result.confidenceLevel ?? l.valuation?.confidence ?? "—"}{live ? ` (${live.result.confidencePct} %)` : ""}</p><Notice>Estimation statistique (hédonique + comparables), pas une évaluation agréée. Méthode, données et limites dans l'onglet « Prix et marché ».</Notice></div>}141          {rent && l.valuation && <div className="tv-card p-4"><p className="tv-label">Loyer de référence Toveli</p><p className="tv-price text-2xl mt-1">{fmtPrice(l.valuation.estimate, { rent: true })}</p><p className="text-xs text-ink-2">{fmtPrice(l.valuation.low)} – {fmtPrice(l.valuation.high)} · confiance {l.valuation.confidence ?? "—"}</p><Notice>Médianes de segment (ville/quartier × chambres) ajustées, voisins géographiques. Indicatif.</Notice></div>}142          <div className="tv-card p-4"><p className="tv-label">Provenance</p><dl className="mt-2 text-xs text-ink-2 space-y-1"><div className="flex justify-between gap-2"><dt>Collecte</dt><dd>site d'origine → Toveli</dd></div><div className="flex justify-between gap-2"><dt>Connecteur</dt><dd>{l.connector}</dd></div><div className="flex justify-between gap-2"><dt>Photos</dt><dd>hotlink, droits du site source</dd></div><div className="flex justify-between gap-2"><dt>Qualité des attributs</dt><dd>{l.quality_score != null ? `${Math.round(l.quality_score)} / 100` : "—"}</dd></div></dl></div>143        </aside>144      </div>145      {nearby.length > 0 && <section className="mt-10"><h2 className="text-xl font-bold tracking-tight mb-3">À proximité</h2><div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">{nearby.map((c) => <ListingCardView key={c.uid} c={c} />)}</div></section>}146    </article>147  );148}149